Understanding State in Flutter
What is State?
State is any data that can change over time and affects what the user sees. When state changes, the UI needs to rebuild to reflect the new data.
Types of State
- Ephemeral State: Local to a single widget (e.g., current tab index, text field value, animation progress). Use
setState(). - App State: Shared across multiple widgets or persists across screens (e.g., user authentication, shopping cart, theme preference). Requires state management solutions.
Key principle: Keep state as local as possible. Only lift state up when multiple widgets need to access it.
Using setState() for Local State
Basic setState Pattern
setState() tells Flutter that the widget's state has changed and the UI needs to rebuild. It should only be used in StatefulWidget.
Simple Counter Example
class CounterWidget extends StatefulWidget {
@override
_CounterWidgetState createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State {
int _count = 0;
void _increment() {
setState(() {
_count++;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $_count'),
ElevatedButton(
onPressed: _increment,
child: Text('Increment'),
),
],
);
}
}
setState Best Practices
- Only mutate state variables inside the
setState()callback - Keep the callback lightweight; do heavy computation before calling setState
- Don't call setState in
build()method - Check
mountedbefore setState in async callbacks
State Lifting Pattern
When to Lift State
When multiple widgets need to access or modify the same state, lift it to their nearest common ancestor.
Example: Shared Counter
// Parent widget holds the state
class CounterScreen extends StatefulWidget {
@override
_CounterScreenState createState() => _CounterScreenState();
}
class _CounterScreenState extends State {
int _count = 0;
void _increment() => setState(() => _count++);
void _decrement() => setState(() => _count--);
@override
Widget build(BuildContext context) {
return Column(
children: [
CounterDisplay(count: _count), // Child reads state
CounterControls(
onIncrement: _increment, // Child modifies state
onDecrement: _decrement,
),
],
);
}
}
// Child widget receives state as parameter
class CounterDisplay extends StatelessWidget {
final int count;
const CounterDisplay({required this.count});
@override
Widget build(BuildContext context) {
return Text('Count: $count');
}
}
// Child widget receives callbacks
class CounterControls extends StatelessWidget {
final VoidCallback onIncrement;
final VoidCallback onDecrement;
const CounterControls({
required this.onIncrement,
required this.onDecrement,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
ElevatedButton(onPressed: onDecrement, child: Text('-')),
ElevatedButton(onPressed: onIncrement, child: Text('+')),
],
);
}
}
ValueNotifier and ValueListenableBuilder
Simple Reactive Updates
For simple state that needs to be shared, ValueNotifier provides a lightweight solution without external packages.
ValueNotifier Example
class ThemeNotifier {
final ValueNotifier isDarkMode = ValueNotifier(false);
void toggleTheme() {
isDarkMode.value = !isDarkMode.value;
}
void dispose() {
isDarkMode.dispose();
}
}
// Usage in widget
class SettingsScreen extends StatefulWidget {
@override
_SettingsScreenState createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State {
final _themeNotifier = ThemeNotifier();
@override
void dispose() {
_themeNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: _themeNotifier.isDarkMode,
builder: (context, isDark, child) {
return Switch(
value: isDark,
onChanged: (_) => _themeNotifier.toggleTheme(),
);
},
);
}
}
When to Use ValueNotifier
- Simple state that doesn't require complex logic
- State shared between a few related widgets
- Prefer over setState when state needs to be accessed from multiple widgets
- Remember to dispose ValueNotifier to prevent memory leaks
Common setState Pitfalls
Pitfall 1: Calling setState in build()
Wrong: Calling setState inside build() causes infinite rebuild loops.
// DON'T DO THIS
@override
Widget build(BuildContext context) {
setState(() { _count++; }); // ❌ Infinite loop!
return Text('Count: $_count');
}
Pitfall 2: setState in Async Callbacks
Wrong: Calling setState after widget is disposed causes errors.
// DON'T DO THIS
Future _loadData() async {
final data = await fetchData();
setState(() { _items = data; }); // ❌ May be called after dispose
}
Correct: Check if widget is still mounted.
// DO THIS
Future _loadData() async {
final data = await fetchData();
if (mounted) { // ✅ Check before setState
setState(() { _items = data; });
}
}
Pitfall 3: Heavy Computation in setState
Wrong: Doing heavy work inside setState blocks the UI.
// DON'T DO THIS
void _processData() {
setState(() {
_result = expensiveComputation(); // ❌ Blocks UI
});
}
Correct: Compute before setState.
// DO THIS
void _processData() {
final result = expensiveComputation(); // ✅ Compute first
setState(() {
_result = result; // ✅ Just assign
});
}
State Management Decision Guide
When to Use Each Approach
- setState: Local widget state that doesn't need to be shared (e.g., form field values, toggle states, animation progress)
- State Lifting: State shared between parent and children widgets (e.g., selected item in a list, form state)
- ValueNotifier: Simple shared state between a few widgets (e.g., theme preference, simple counters)
- Provider/State Management Packages: Complex app-wide state, business logic, async operations (covered in Chapter 12)
Practical Example: Todo List
Complete Example
class TodoItem {
final String id;
final String text;
bool isCompleted;
TodoItem({required this.id, required this.text, this.isCompleted = false});
}
class TodoScreen extends StatefulWidget {
@override
_TodoScreenState createState() => _TodoScreenState();
}
class _TodoScreenState extends State {
final List _todos = [];
final _textController = TextEditingController();
@override
void dispose() {
_textController.dispose();
super.dispose();
}
void _addTodo(String text) {
if (text.trim().isEmpty) return;
setState(() {
_todos.add(TodoItem(
id: DateTime.now().toString(),
text: text,
));
});
_textController.clear();
}
void _toggleTodo(String id) {
setState(() {
final todo = _todos.firstWhere((t) => t.id == id);
todo.isCompleted = !todo.isCompleted;
});
}
void _deleteTodo(String id) {
setState(() {
_todos.removeWhere((t) => t.id == id);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Todo List')),
body: Column(
children: [
Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
decoration: InputDecoration(hintText: 'Add todo'),
onSubmitted: _addTodo,
),
),
IconButton(
icon: Icon(Icons.add),
onPressed: () => _addTodo(_textController.text),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return ListTile(
title: Text(
todo.text,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: null,
),
),
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => _toggleTodo(todo.id),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => _deleteTodo(todo.id),
),
);
},
),
),
],
),
);
}
}
Exercises
1. Counter with History
Create a counter app that tracks the count history. Display the current count and a list of all previous counts. Add buttons to increment, decrement, and reset. Use setState to manage the state.
2. Temperature Converter
Build a temperature converter that converts between Celsius and Fahrenheit. Use state lifting: parent widget holds the temperature value, child widgets display and modify it. Include input validation.
3. Shopping Cart with ValueNotifier
Create a simple shopping cart using ValueNotifier to manage cart items. Display items, quantities, and total price. Use ValueListenableBuilder to update the UI when cart changes. Include add/remove functionality.